记录macOS在actions里公证的相关问题
之前在给自己的项目做桌面客户端分发(Tauri,Windows + macOS 双平台)。开发机是 Windows,手头唯一的苹果设备是一台 2016 年的老 Intel MacBook,遇到的问题是 Tauri 无法从 Windows 交叉编译 macOS 产物,所以 macOS 的构建、签名、公证整条链路只能交给 GitHub Actions进行处理
由于本人对桌面端并不熟悉,所以这篇把这条链上踩过的坑记录一下:CI 计费的隐藏倍率、公证首单的排队问题、公证结果"绑定文件哈希而不是版本号"引发的事故,以及三个写 workflow 时踩过才知道的坑
私有仓库的免费额度(Free 计划)是每月 2000 分钟,但很多人不知道这 2000 分钟是按 Linux 分钟记的,不同系统有倍率:
| Runner | 计费倍率 | 超额单价 |
|---|---|---|
| Linux | 1x | $0.008/min |
| Windows | 2x | $0.016/min |
| macOS | 10x | $0.08/min |
也就是说,如果把配额全部拿来跑macOS,那么实际每月只有约 200 分钟真实机时 如果你和我一样在actions上跑win和macOS的构建,那么需要考虑一下配额的问题
我是怎么发现这件事的:早期把公证也放在 runner 上做,xcrun notarytool submit --wait 挂在那里一直等待 Apple 返回结果,一等 15 分钟起步——15 真实分钟 × 10 倍率 = 一次吃掉 150 多分钟额度。项目刚出来的几个版本,一周多的时间额度已经用掉 1350 分钟,构建的时候随手点进overview页面才注意到这一点
后来算过一笔稳定账:双平台各构建一次(Windows 构建 + macOS 双架构构建加签名)约消耗 180–280 额度分钟,免费额度大约够每月发版 7–11 次;超出后 macOS 部分约 $1.6 一次。不贵,但你得知道这笔账存在,否则在不注意的情况下就会莫名其妙用光
结论:凡是"纯等待"的步骤(公证轮询、外部审核),能不占 macOS runner 就不占。 至于公证这一步本身放不放 CI,下面会讲到一次完整的决策反转
Apple Developer 账号审核结束批下来后,第一次跑公证。Apple的文档说"通常几分钟"
至于我这个个人项目实测:80 小时
Apple社区相关的讨论也有很多,很多开发者发帖或者评论说自己的公证卡住了很久,社区管理者给出的答复通常就是‘这是正常的,再等一下’ 全新 Team ID 的首批提交会被 Apple 拉去做深度审查(官方说是 "in-depth analysis"),论坛上等 24–72 小时以上的案例一抓一把。这期间有两个需要注意的点:
首单通过之后,Apple 的系统就"认识"你的应用了——后续公证的步骤大约只需要 1-2 分钟就返回 Accepted
所以如果你是新账号、计划里有"发 macOS 包给测试用户"这一项,把"首次公证可能要等几天"当成一个真实的里程碑排进日程。它是 macOS 分发链路上最不可控的一环,好在只发生一次。比起万事俱备再发布初始版本-跑公证,早点做出一个公证版本先提交了可能是更好的选择
还好在项目正式上线前学到了这一点
背景知识:notarytool submit 返回 Accepted 之后,文件本身没有任何变化——公证的凭证(Apple 叫 ticket)存在 Apple 的服务器上。你需要再执行 xcrun stapler staple <dmg> 把凭证附加进文件里,用户离线安装时 Gatekeeper 才能在本地完成校验
而这份凭证绑定的是具体二进制文件的哈希(CDHash),不是版本号
事故经过:熬完 80 小时、公证并 staple 好的是某个版本的构建 A,我把它传上了分发用的对象存储(丢在CF的R2上)。后来因为改了点别的东西,从 CI 重新下载了同一个版本号的构建 B(签过名、没公证)覆盖上去——版本号一样,我以为没事
用户下载,Gatekeeper 拦截
同版本号、不同构建 = 不同哈希 = 公证凭证不覆盖。所以:分发服务器上放的必须是你亲手 staple 过的那一个文件,精确到字节的那一个。 换了构建,整套 submit → staple → validate 流程重做(好在首单之后每次只要一两分钟)
配套还有两个验证陷阱,不知道的话你会误判自己修好了 或者 没问题:
com.apple.quarantine 属性——浏览器只给最外层的 zip 打隔离标记,里面套着的 dmg 和 .app 传不进去。所以自己测试时"直接打开、什么都没弹"完全不代表用户那边没问题:真实用户从浏览器直接下载 dmg,一定带隔离标记,一定要过 Gatekeeper。想模拟真实场景,用 xattr -w com.apple.quarantine 手动补一个标记再测。验证命令备忘:stapler validate <file> 检查凭证是否已附加,spctl -a -t open --context context:primary-signature <dmg> 模拟 Gatekeeper 的裁决。另外 notarytool store-credentials 可以把 Apple ID 凭据存进钥匙串,后续提交和查日志不用每次重填。
首次 80 小时 + 10 倍计费这两件事叠在一起的时候,我定了条规矩:公证绝不放 runner 上做。CI 只负责产出满足公证条件的签名 dmg(Developer ID 签名 + hardened runtime + entitlements 齐全,约 1.5 分钟),本地那台老 Mac 从actions里面下dmg然后手动提交公证、手动 staple。当时的理由很充分:不烧 10 倍的机时、本地能实时看进度、能用 notarytool log 查被拒原因,新账号的"首批慢"也在免费的本机吸收掉。
几个版本之后我把这条规矩推翻了:首单通过后公证只要 1 分钟,在 runner 上等这 1 分钟只花 10 分钟额度,完全可以接受。于是公证收回 CI 全自动:构建时自动提交公证并 staple,发布环节先校验公证确实通过、才把固定文件名的下载链接刷新成新版本——校验不过就保持原样,坏产物不会流到用户手里。
1. GitHub 表达式的 null 强转会把构建步悄悄跳过。
if: ${{ vars.MAC_IN_RELEASE != '0' }}
看起来是"默认开启、设成 0 才跳过"。但当这个变量根本不存在时,表达式是 null != '0'——GitHub 的表达式引擎把两边都强转成数字,null → 0,'0' → 0,结果为 false,macOS 构建被静默跳过。修法是显式接住 null:vars.MAC_IN_RELEASE == '' || vars.MAC_IN_RELEASE != '0'。
更坑的是补救路径:点 Re-run 时永远使用 tag 那个 commit 上的旧 workflow 文件,你把 workflow 修好再 push 也救不了当前这一轮——当轮的救法只有先把变量创建出来,再 Re-run all jobs。
2. Tauri 的 --bundles 参数必须显式包含 app。
macOS 上如果只写 --bundles dmg,.app 的打包步骤不会执行,而自动更新的产物 .app.tar.gz 依赖这一步——结果 dmg 好好的,自动更新的安装包凭空消失,直到用户端检查更新才暴露。
3. Tauri 的自动公证只覆盖到 .app 这一层,dmg 要单独再公证一次。
Tauri 的公证发生在 .app 阶段,dmg 在那之后才打包、只签名不公证——对 dmg 跑 stapler validate 直接失败。修法是在 CI 里对 dmg 单独再提交一次公证(里面的 .app 已经有凭证,Apple 几乎秒批)再 staple。注意 staple 会修改 dmg 文件本身,所以"把产物拷去发布"必须排在 staple 之后——顺序反了,就又回到第三节那个事故。
Gatekeeper 只检查带隔离标记(quarantine)的文件,而应用内更新器自己下载的 .app.tar.gz 不经过浏览器、不会被打上隔离标记——所以自动更新的产物免公证,只需要更新器自身的签名校验。这一点已在真机上验证过:装着旧版本的 Mac 一路自动更新到新版,全程没有 Gatekeeper 介入
于是最终形态是:用户首次下载的 dmg 每版公证 + staple(CI 全自动),自动更新链路零公证成本。 需要"过 Apple"的只有用户第一次从浏览器下载的那一个文件
评论加载中...